/*
 * Copyright (C)  Tony Green, LitePal Framework Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.litepal.tablemanager;


import ohos.data.rdb.RdbStore;
import ohos.data.resultset.ResultSet;
import org.litepal.exceptions.DatabaseGenerateException;
import org.litepal.tablemanager.model.AssociationsModel;
import org.litepal.tablemanager.model.ColumnModel;
import org.litepal.tablemanager.model.GenericModel;
import org.litepal.util.*;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;

/**
 * When models have associations such as one2one, many2one or many2many, tables
 * should add foreign key column or create intermediate table to make the object
 * association mapping right. This process will be proceed automatically without
 * concerning by users. To make this happen, user just need to declare the
 * associations clearly in the models, and make sure all the mapping models are
 * added in the litepal.xml file.
 * 
 * @author Tony Green
 * @since 1.0
 */
public abstract class AssociationCreator extends Generator {

    protected abstract void createOrUpgradeTable(RdbStore db, boolean force);

    /**
     * {@link AssociationCreator} analyzes two things. Add associations
     * including add foreign key column to tables and create intermediate join
     * tables.
     *
     * @param db RdbStore
     * @param force boolean
     */
    @Override
    protected void addOrUpdateAssociation(RdbStore db, boolean force) {
        addAssociations(getAllAssociations(), db, force);
    }

    /**
     * Generate a create table SQL by the passed in parameters. Note that it
     * will always generate a SQL with id/_id column in it as primary key and
     * this id is auto increment as integer if the autoIncrementId is true, or
     * no primary key will be added.
     * 
     * @param tableName
     *            The table name.
     * @param columnModels
     *            A list contains all column models with column info.
     * @param autoIncrementId
     *            Generate an auto increment id or not. Only intermediate join table doesn't need
     *            an auto increment id.
     * @return A generated create table SQL.
     */
    protected String generateCreateTableSQL(String tableName, Collection<ColumnModel> columnModels,
            boolean autoIncrementId) {
        StringBuilder createTableSQL = new StringBuilder("create table ");
        createTableSQL.append(tableName).append(" (");
        if (autoIncrementId) {
            createTableSQL.append("id integer primary key autoincrement,");
        }
        if (isContainsOnlyIdField(columnModels)) {
            // Remove the last comma when only have id field in model.
            createTableSQL.deleteCharAt(createTableSQL.length() - 1);
        }
        boolean needSeparator = false;
        for (ColumnModel columnModel : columnModels) {
            if (columnModel.isIdColumn()) {
                continue;
            }
            if (needSeparator) {
                createTableSQL.append(", ");
            }
            needSeparator = true;
            createTableSQL.append(columnModel.getColumnName()).append(" ").append(columnModel.getColumnType());
            if (!columnModel.isNullable()) {
                createTableSQL.append(" not null");
            }
            if (columnModel.isUnique()) {
                createTableSQL.append(" unique");
            }
            String defaultValue = columnModel.getDefaultValue();
            if (!TextUtils.isEmpty(defaultValue)) {
                createTableSQL.append(" default ").append(defaultValue);
            }
        }
        createTableSQL.append(")");
        LitePalLog.d(TAG, "create table sql is >> " + createTableSQL);
        return createTableSQL.toString();
    }

    /**
     * Generate create index SQLs by the passed in parameters.
     *
     * @param tableName
     *            The table name.
     * @param columnModels
     *            A list contains all column models with column info.
     * @return A generated create index SQLs.
     */
    protected List<String> generateCreateIndexSQLs(String tableName, Collection<ColumnModel> columnModels) {
        List<String> sqls = new ArrayList<>();
        for (ColumnModel columnModel : columnModels) {
            if (columnModel.hasIndex()) {
                sqls.add(generateCreateIndexSQL(tableName, columnModel));
            }
        }
        return sqls;
    }

    /**
     * Generate a SQL for dropping table.
     * 
     * @param tableName
     *            The table name.
     * @return A SQL to drop table.
     */
    protected String generateDropTableSQL(String tableName) {
        return "drop table if exists " + tableName;
    }

    /**
     * Generate a SQL for add new column into the existing table.
     * @param tableName
     *          The table which want to add a column
     * @param columnModel
     *          Which contains column info
     * @return A SQL to add new column.
     */
    protected String generateAddColumnSQL(String tableName, ColumnModel columnModel) {
        StringBuilder addColumnSQL = new StringBuilder();
        addColumnSQL.append("alter table ").append(tableName);
        addColumnSQL.append(" add column ").append(columnModel.getColumnName());
        addColumnSQL.append(" ").append(columnModel.getColumnType());
        if (!columnModel.isNullable()) {
            addColumnSQL.append(" not null");
        }
        if (columnModel.isUnique()) {
            addColumnSQL.append(" unique");
        }
        String defaultValue = columnModel.getDefaultValue();
        if (!TextUtils.isEmpty(defaultValue)) {
            addColumnSQL.append(" default ").append(defaultValue);
        } else {
            if (!columnModel.isNullable()) {
                if ("integer".equalsIgnoreCase(columnModel.getColumnType())) {
                    defaultValue = "0";
                } else if ("text".equalsIgnoreCase(columnModel.getColumnType())) {
                    defaultValue = "''";
                } else if ("real".equalsIgnoreCase(columnModel.getColumnType())) {
                    defaultValue = "0.0";
                }
                addColumnSQL.append(" default ").append(defaultValue);
            }
        }
        LitePalLog.d(TAG, "add column sql is >> " + addColumnSQL);
        return addColumnSQL.toString();
    }

    /**
     * Generate create index SQL by the passed in parameters.
     *
     * @param tableName
     *            The table name.
     * @param columnModel
     *            Column model with column info.
     * @return A generated create index SQL.
     */
    protected String generateCreateIndexSQL(String tableName, ColumnModel columnModel) {
        StringBuilder createIndexSQL = new StringBuilder();
        if (columnModel.hasIndex()) {
            createIndexSQL.append("create index ");
            createIndexSQL.append(DBUtility.getIndexName(tableName, columnModel.getColumnName()));
            createIndexSQL.append(" on ");
            createIndexSQL.append(tableName);
            createIndexSQL.append(" (");
            createIndexSQL.append(columnModel.getColumnName());
            createIndexSQL.append(")");
            LitePalLog.d(TAG, "create table index sql is >> " + createIndexSQL);
        }
        return createIndexSQL.toString();
    }

    /**
     * Judge the passed in column is a foreign key column format or not. Each
     * column name ends with _id will be considered as foreign key column
     * format.
     * 
     * @param columnName
     *            The name of column.
     * @return Return true if it's foreign column format, otherwise return
     *         false.
     */
    protected boolean isForeignKeyColumnFormat(String columnName) {
        if (!TextUtils.isEmpty(columnName)) {
            return columnName.toLowerCase(Locale.US).endsWith("_id") && !columnName.equalsIgnoreCase("_id");
        }
        return false;
    }

    /**
     * Once there's new table created. The table name will be saved into
     * table_schema as a copy. Each table name will be saved only once.
     * 
     * @param tableName
     *            The table name.
     * @param tableType
     *            0 means normal table, 1 means intermediate join table.
     * @param db
     *            Instance of RdbStore.
     */
    protected void giveTableSchemaACopy(String tableName, int tableType, RdbStore db) {
        StringBuilder sql = new StringBuilder("select * from ");
        sql.append(Const.TableSchema.TABLE_NAME);
        LitePalLog.d(TAG, "giveTableSchemaACopy SQL is >> " + sql);
        ResultSet cursor = null;
        try {
            cursor = db.querySql(sql.toString(), null);
            if (isNeedtoGiveACopy(cursor, tableName)) {
                LiteValuesBucket values = new LiteValuesBucket();
                values.putString(Const.TableSchema.COLUMN_NAME, BaseUtility.changeCase(tableName));
                values.putInteger(Const.TableSchema.COLUMN_TYPE, tableType);
                db.insert(Const.TableSchema.TABLE_NAME, values);
            }
        } catch (Exception e) {
            Log.e(e);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Save the name of a created table into table_schema, but there're some
     * extra rules. Each table name should be only saved once, and special
     * tables will not be saved.
     * 
     * @param cursor
     *            The cursor used to iterator values in the table.
     * @param tableName
     *            The table name.
     * @return If all rules are passed return true, any of them failed return
     *         false.
     */
    private boolean isNeedtoGiveACopy(ResultSet cursor, String tableName) {
        return !isValueExists(cursor, tableName) && !isSpecialTable(tableName);
    }

    /**
     * Judge the table name has already exist in the table_schema or not.
     * 
     * @param cursor
     *            The cursor used to iterator values in the table.
     * @param tableName
     *            The table name.
     * @return If value exists return true, or return false.
     */
    private boolean isValueExists(ResultSet cursor, String tableName) {
        boolean exist = false;
        if (cursor.goToFirstRow()) {
            do {
                String name = cursor.getString(cursor
                        .getColumnIndexForName(Const.TableSchema.COLUMN_NAME));
                if (name.equalsIgnoreCase(tableName)) {
                    exist = true;
                    break;
                }
            } while (cursor.goToNextRow());
        }
        return exist;
    }

    /**
     * Judge a table is a special table or not. Currently table_schema is a
     * special table.
     * 
     * @param tableName
     *            The table name.
     * @return Return true if it's special table.
     */
    private boolean isSpecialTable(String tableName) {
        return Const.TableSchema.TABLE_NAME.equalsIgnoreCase(tableName);
    }

    /**
     * Analyzing all the association models in the collection. Judge their
     * association types. If it's one2one or many2one associations, add the
     * foreign key column to the associated table. If it's many2many
     * associations, create an intermediate join table.
     * 
     * @param associatedModels
     *            A collection contains all the association models.Use the
     *            association models to get association type and associated
     *            table names.
     * @param db
     *            Instance of RdbStore.
     * @param force
     *            Drop the table first if it already exists.
     */
    private void addAssociations(Collection<AssociationsModel> associatedModels, RdbStore db,
            boolean force) {
        for (AssociationsModel associationModel : associatedModels) {
            if (Const.Model.MANY_TO_ONE == associationModel.getAssociationType()
                    || Const.Model.ONE_TO_ONE == associationModel.getAssociationType()) {
                addForeignKeyColumn(associationModel.getTableName(),
                        associationModel.getAssociatedTableName(),
                        associationModel.getTableHoldsForeignKey(), db);
            } else if (Const.Model.MANY_TO_MANY == associationModel.getAssociationType()) {
                createIntermediateTable(associationModel.getTableName(),
                        associationModel.getAssociatedTableName(), db, force);
            }
        }
        for (GenericModel genericModel : getGenericModels()) {
            createGenericTable(genericModel, db, force);
        }
    }

    /**
     * When it comes to many2many associations. Database need to create an
     * intermediate table for mapping this association. This method helps create
     * such a table, and the table name follows the concatenation of the two
     * target table names in alphabetical order with underline in the middle.
     * 
     * @param tableName
     *            The table name.
     * @param associatedTableName
     *            The associated table name.
     * @param db
     *            Instance of RdbStore.
     * @param force
     *            Drop the table first if it already exists.
     */
    private void createIntermediateTable(String tableName, String associatedTableName,
            RdbStore db, boolean force) {
        List<ColumnModel> columnModelList = new ArrayList<>();
        ColumnModel column1 = new ColumnModel();
        column1.setColumnName(tableName + "_id");
        column1.setColumnType("integer");
        ColumnModel column2 = new ColumnModel();
        column2.setColumnName(associatedTableName + "_id");
        column2.setColumnType("integer");
        columnModelList.add(column1);
        columnModelList.add(column2);
        String intermediateTableName = DBUtility.getIntermediateTableName(tableName,
                associatedTableName);
        List<String> sqls = new ArrayList<>();
        if (DBUtility.isTableExists(intermediateTableName, db)) {
            if (force) {
                sqls.add(generateDropTableSQL(intermediateTableName));
                sqls.add(generateCreateTableSQL(intermediateTableName, columnModelList, false));
            }
        } else {
            sqls.add(generateCreateTableSQL(intermediateTableName, columnModelList, false));
        }
        execute(sqls, db);
        giveTableSchemaACopy(intermediateTableName, Const.TableSchema.INTERMEDIATE_JOIN_TABLE, db);
    }

    /**
     * When declared generic collection fields in model class. Database need to create
     * generic tables for mapping these fields. This method helps create such a table.
     *
     * @param genericModel
     *          The GenericModel instance.
     * @param db
     *          Instance of RdbStore.
     * @param force
     *          Drop the table first if it already exists.
     */
    private void createGenericTable(GenericModel genericModel, RdbStore db, boolean force) {
        String tableName = genericModel.getTableName();
        String valueColumnName = genericModel.getValueColumnName();
        String valueColumnType = genericModel.getValueColumnType();
        String valueIdColumnName = genericModel.getValueIdColumnName();
        List<ColumnModel> columnModelList = new ArrayList<>();
        ColumnModel column1 = new ColumnModel();
        column1.setColumnName(valueColumnName);
        column1.setColumnType(valueColumnType);
        ColumnModel column2 = new ColumnModel();
        column2.setColumnName(valueIdColumnName);
        column2.setColumnType("integer");
        columnModelList.add(column1);
        columnModelList.add(column2);
        List<String> sqls = new ArrayList<>();
        if (DBUtility.isTableExists(tableName, db)) {
            if (force) {
                sqls.add(generateDropTableSQL(tableName));
                sqls.add(generateCreateTableSQL(tableName, columnModelList, false));
            }
        } else {
            sqls.add(generateCreateTableSQL(tableName, columnModelList, false));
        }
        execute(sqls, db);
        giveTableSchemaACopy(tableName, Const.TableSchema.GENERIC_TABLE, db);
    }

    /**
     * This method is used to add many to one association or one to one
     * association on tables. It will automatically build a SQL to add foreign
     * key to a table. If the passed in table name or associated table name
     * doesn't exist, it will throw an exception.
     * 
     * @param tableName
     *            The table name.
     * @param associatedTableName
     *            The associated table name.
     * @param tableHoldsForeignKey
     *            The table which holds the foreign key.
     * @param db
     *            Instance of RdbStore.
     */
    protected void addForeignKeyColumn(String tableName, String associatedTableName,
            String tableHoldsForeignKey, RdbStore db) {
        if (DBUtility.isTableExists(tableName, db)) {
            if (DBUtility.isTableExists(associatedTableName, db)) {
                String foreignKeyColumn = null;
                if (tableName.equals(tableHoldsForeignKey)) {
                    foreignKeyColumn = getForeignKeyColumnName(associatedTableName);
                } else if (associatedTableName.equals(tableHoldsForeignKey)) {
                    foreignKeyColumn = getForeignKeyColumnName(tableName);
                }
                if (!DBUtility.isColumnExists(foreignKeyColumn, tableHoldsForeignKey, db)) {
                    ColumnModel columnModel = new ColumnModel();
                    columnModel.setColumnName(foreignKeyColumn);
                    columnModel.setColumnType("integer");
                    List<String> sqls = new ArrayList<>();
                    sqls.add(generateAddColumnSQL(tableHoldsForeignKey, columnModel));
                    execute(sqls, db);
                } else {
                    LitePalLog.d(TAG, "column " + foreignKeyColumn
                            + " is already exist, no need to add one");
                }
            } else {
                throw new DatabaseGenerateException(DatabaseGenerateException.TABLE_DOES_NOT_EXIST
                        + associatedTableName);
            }
        } else {
            throw new DatabaseGenerateException(DatabaseGenerateException.TABLE_DOES_NOT_EXIST
                    + tableName);
        }
    }

    /**
     * Check if the ColumnModel list contains only id field.
     * @param columnModels
     *          List contains model fields.
     * @return If ColumnModel list is empty or contains only id, _id field, return true. Otherwise return false.
     */
    private boolean isContainsOnlyIdField(Collection<ColumnModel> columnModels) {
        for (ColumnModel columnModel : columnModels) {
            if (!columnModel.isIdColumn()) return false;
        }
        return true;
    }

}

